feat: add deploy bundle command for downloaded bundles#793
Conversation
Add `rsconnect deploy bundle <bundle.tar.gz>` to deploy a previously built content bundle (such as one downloaded from a Connect server) directly to a server. The bundle is uploaded as-is; its existing manifest.json determines the content type and dependencies, making it easy to copy content between servers. Rather than extract and re-bundle, this reuses the existing executor deploy chain: make_bundle simply opens the tarball and deploy_bundle uploads it unchanged. New bundle.py helpers read the app mode and default title from the tarball's manifest.json without full extraction. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
prepare_deploy_metadata now receives already-detected metadata instead of a directory to inspect, so callers decide whether to auto-detect git metadata. The existing deploy commands pass detect_git_metadata(base_dir); deploy bundle passes an empty dict so no git metadata is auto-attached. A bundle's location on disk is unrelated to the content's source, so detecting git metadata from it would attach misleading provenance. Only explicit --metadata overrides are sent for bundle deployments. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- default_title_from_bundle now falls back to the bundle's own filename (e.g. "mycontent" from "mycontent.tar.gz") when the manifest has no usable entrypoint, instead of the directory the bundle happens to live in, which is unrelated to the content's identity (roborev #24, medium). - Add a CLI-level test (test_deploy_bundle) covering the full deploy flow and asserting the tarball is uploaded unchanged (roborev #24, low). - Add unit tests for the filename fallback and a .tar.gz/.tgz extension stripping helper. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A bundle filename like my.cool.api.tar.gz was truncated to "my.cool":
after stripping the archive extension, _default_title ran a second
rsplit(".", 1) on the result. Extract length enforcement into
_enforce_title_length and format the pre-stripped bundle name directly,
so dotted stems are preserved (roborev #26, low).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
☂️ Python Coverage
Overall Coverage
New FilesNo new covered files... Modified Files
|
| return _default_title(filename) | ||
|
|
||
|
|
||
| def _strip_bundle_extension(name: str) -> str: |
There was a problem hiding this comment.
Connect always returns .tar.gz so we don't need this affordance.
- Revert prepare_deploy_metadata to take directory: Optional[str] and detect git metadata internally; bundle deployment passes directory=None to skip auto-detection, rather than extracting detect_git_metadata to every call site. - Simplify bundle title fallback to only strip .tar.gz (Connect always produces .tar.gz bundles), dropping the .tgz/.tar affordance. - Collapse the metadata assignment in deploy bundle to a single line. - Move the deploy bundle CHANGELOG entry to the bottom of "Added". Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
open_bundle is a no-op "builder" passed to RSConnectExecutor.make_bundle, which expects a callable returning a file-like bundle. Document that for deploy bundle the tarball already exists, so we just open() it and route through make_bundle to reuse the existing deployment-name and upload flow. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The raw manifest string was modeled after read_manifest_file, where it is re-added to a freshly built tarball. deploy bundle uploads the bundle as-is and never rebuilds it, so every caller discarded the raw string. Return just the parsed ManifestData. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
This is great!
One caveat. When deploying, Connect checks if the bundle contains only a single subdirectory, if so, it moves everything from that subdirectory up to the parent and removes the empty subdirectory. It loops until this is no longer the case and this only triggers when the extraction directory contains exactly one entry and that entry is a directory.
That it because it is common for users to end up with tarballs in a shape like:
/my-app
| -- /bundle
|-- app.py
|-- manifest.json
So Connect knows how to work with nested dirs like that, but when we download the bundle from Connect it comes with the original shape.
For those cases, trying to deploy a tar with the current approach in this PR, it fails with:
Error: Bundle "bundle-gradio.tar.gz" does not contain a manifest.json file.
I think we should handle that scenario too here.
Note:
macOS Archive Utility is smart. Similarly, when it detects a single top-level directory inside an archive (in this case ./bundle/), it extracts that directory directly and renames it to match the archive filename. So, when trying this out you could think the tarball has only one level when it does not.
Successful Output for Shiny R .tar.gz bundle
$ uv run rsconnect deploy bundle bundle-shiny-r.tar.gz
Validating server... [OK]
Validating app mode... [OK]
Making bundle ... [OK]
Deploying bundle ... [OK]
Saving deployed information... [OK]
Building Shiny application...
Bundle created with R version 4.5.2 (~=4.5.0) is compatible with environment Local with R version 4.5.0 from /opt/R/4.5.0/bin/R
Bundle requested R version 4.5.2 (~=4.5.0); using /opt/R/4.5.0/bin/R from Local which has version 4.5.0
Performing manifest.json to packrat transformation.
2026/06/24 17:31:08.494479474 [connect-session] Connect Session v2026.06.0-dev+356-g1126d83992
2026/06/24 17:31:08.494838313 [connect-session] Content GUID: 5b07b7a8-1b6b-4f08-af12-76509242170d
2026/06/24 17:31:08.494852684 [connect-session] Content ID: 108505
2026/06/24 17:31:08.494858224 [connect-session] Bundle ID: 348303
2026/06/24 17:31:08.494863085 [connect-session] Job Key: Kdsy1SkTM8a9zFOo
Job started
Determining session server location ...
Connecting to session server http://127.0.0.1:39291 ...
Connected to session server http://127.0.0.1:39291
Starting content session token refresher (interval: 12h0m0s)
2026/06/24 17:31:08.719797660 Running on host: ip-10-0-35-196
2026/06/24 17:31:08.719807301 Process ID: 2091191
2026/06/24 17:31:08.732908866 Linux distribution: Ubuntu 22.04.5 LTS (jammy)
2026/06/24 17:31:08.737122505 Running as user: uid=998(rstudio-connect) gid=999(rstudio-connect) groups=999(rstudio-connect)
2026/06/24 17:31:08.737132165 Connect version: 2026.06.0-dev+356-g1126d83992
...
2026/06/24 17:31:10.306743827 Installing promises (1.5.0) ...
2026/06/24 17:31:10.317820669 OK (symlinked cache)
2026/06/24 17:31:10.317930638 Installing bslib (0.10.0) ...
2026/06/24 17:31:10.329127599 OK (symlinked cache)
2026/06/24 17:31:10.329286582 Installing httpuv (1.6.17) ...
2026/06/24 17:31:10.341528567 OK (symlinked cache)
2026/06/24 17:31:10.341655698 Installing shiny (1.13.0) ...
2026/06/24 17:31:10.352101718 OK (symlinked cache)
Completed packrat build using Local against R version: '4.5.0'
Stopped session pings to http://127.0.0.1:39291
Stopping content session token refresher
Job completed
Launching Shiny application...
Deployment completed successfully.
Dashboard content URL: https://dogfood.team.pct.posit.it/connect/#/apps/5b07b7a8-1b6b-4f08-af12-76509242170d
Direct content URL: https://dogfood.team.pct.posit.it/content/5b07b7a8-1b6b-4f08-af12-76509242170d/
Verifying deployed content... [OK]
Successful Output for a FastAPI .tar.gz bundle
$ uv run rsconnect deploy bundle bundle-fastapi.tar.gz
Validating server... [OK]
Validating app mode... [OK]
Making bundle ... [OK]
Deploying bundle ... [OK]
Saving deployed information... [OK]
Building FastAPI application...
Bundle created with Python version 3.11.4 is compatible with environment Local with Python version 3.11.12 from /opt/python/3.11.12/bin/python3.11
Bundle requested Python version 3.11.4; using /opt/python/3.11.12/bin/python3.11 from Local which has version 3.11.12
2026/06/24 17:33:13.309812022 [connect-session] Connect Session v2026.06.0-dev+356-g1126d83992
2026/06/24 17:33:13.310132708 [connect-session] Received trace context: traceID=020bff2d82831b8c2a7e2e4b7993ce3f
2026/06/24 17:33:13.310145929 [connect-session] Content GUID: 0f233bc8-0c12-4683-86e3-f468bf279118
2026/06/24 17:33:13.310152830 [connect-session] Content ID: 108506
2026/06/24 17:33:13.310158750 [connect-session] Bundle ID: 348304
2026/06/24 17:33:13.310164501 [connect-session] Job Key: dgMtwvLz65EJALFt
2026/06/24 17:33:13.310170881 [connect-session] WARNING: Publishing with rsconnect-python or Publisher, upgrade for the generated manifest.json to follow version constraints best practices.
2026/06/24 17:33:13.310176302 [connect-session] For more details on version matching, see https://docs.posit.co/connect/admin/python/#python-version-matching
Job started
Determining session server location ...
Connecting to session server http://127.0.0.1:46533 ...
Connected to session server http://127.0.0.1:46533
Starting content session token refresher (interval: 12h0m0s)
2026/06/24 17:33:13.427813946 Running on host: ip-10-0-35-196
2026/06/24 17:33:13.439785229 Process ID: 2093117
2026/06/24 17:33:13.455290876 Linux distribution: Ubuntu 22.04.5 LTS (jammy)
2026/06/24 17:33:13.462761127 Running as user: uid=998(rstudio-connect) gid=999(rstudio-connect) groups=999(rstudio-connect)
2026/06/24 17:33:13.466502308 Connect version: 2026.06.0-dev+356-g1126d83992
...
Stopped session pings to http://127.0.0.1:46533
Stopping content session token refresher
Job completed
Launching FastAPI application...
Deployment completed successfully.
Dashboard content URL: https://dogfood.team.pct.posit.it/connect/#/apps/0f233bc8-0c12-4683-86e3-f468bf279118
Direct content URL: https://dogfood.team.pct.posit.it/content/0f233bc8-0c12-4683-86e3-f468bf279118/
Verifying deployed content... [OK]
Connect collapses a bundle whose extraction root holds a single subdirectory, moving its contents up a level and repeating. Downloaded bundles therefore commonly nest manifest.json under a top-level directory (e.g. "bundle/manifest.json"). read_bundle_manifest only looked at the tar root, so deploying such a bundle failed with "does not contain a manifest.json file" even though Connect would have accepted it. Mirror Connect's single-subdirectory collapse when locating the manifest, so app mode and default title are read correctly. The upload path is unchanged: the bundle is still sent as-is and Connect performs its own collapse. Reported by @marcosnav in PR review. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
(this was claude) Great catch, thanks @marcosnav. Fixed in 843eaac.
The upload path is unchanged — we still send the tarball as-is and let Connect do its own collapse server-side; this only affects how we read the manifest locally for app mode and the default title. Added tests covering single-level nesting, multi-level nesting, |
marcosnav
left a comment
There was a problem hiding this comment.
Tried it out with a nested dirs bundle and works great, the error went away!
![]()
Summary
Adds a new
rsconnect deploy bundlecommand for deploying a previously builtcontent bundle (a
.tar.gz, such as one downloaded from a Connect server)directly to a server. The bundle is uploaded as-is and its existing
manifest.jsondetermines the content type and dependencies, making it easy tocopy content from one server to another.
fixes #790
Details
deploy bundlesubcommand inmain.py, sharing options withdeploy manifest.bundle.py: validates the archive, derives a sensibledefault title from the bundle filename (preserving dots).
prepare_deploy_metadatagit-metadata detection refactored into its ownhelper.
docs/deploying.mdand aCHANGELOG entry, including a note that bundles don't carry environment
variables/secrets and that the target server needs a compatible Python/R
version.
Testing
tests/test_bundle.py,tests/test_main.py, andtests/test_git_metadata.py.🤖 Generated with Claude Code